Until Visual Basic 4, the term class events could refer only to the internal Class_Initialize and Class_Terminate events that the Visual Basic runtime fires when an object is created and destroyed. In versions 5 and 6, however, classes are able to raise events to the outside, exactly as controls and forms can. This ability dramatically increases the potential of class modules, making it possible to integrate them more tightly in applications while continuing to consider them separate and reusable modules.
Before showing how a class module can expose events to the outside and how the client code can trap them, let me explain why events are so important for code reuse. The ability to create a piece of code that can be recycled as is in other projects is too tantalizing to leave any programmer indifferent to the possibility. To illustrate the concept, I'll describe an imaginary class module whose primary job is copying a series of files and optionally informing the caller about the progress of the operation (so that the caller code can display a progress bar or a message on the status bar for the user). Without events, we have two possible ways to implement this code, both of which are clearly unsatisfactory:
Finally events made their appearance and offered the best solution so far to the dilemma:
Implementing events in a class module and using them in a client module is a straightforward process, which consists of just a few logical, easy steps. Figure 7-1 shows how the implementation works. I'm using as an example the hypothetical CFileOp class, which copies multiple files, as I described previously.
Figure 7-1. Implementing events in a class module.
To expose an event to its clients, a class must include an Event statement in its declaration section. This statement serves to inform the outside world about the event's name as well as its arguments. For example, the CFileOp class might expose this event:
Event FileCopyComplete(File As String, DestPath As String) |
There's nothing special about the syntax of arguments, and you can in fact declare arguments of any type supported by Visual Basic, including objects, collections, and Enum values.
When the time comes for the class to raise an event, it executes a RaiseEvent statement, which specifies both the event name and its actual arguments. Again, this isn't conceptually different from calling a procedure, and you'll also find that Microsoft IntelliSense can give you a hand in both selecting the event name and the values for its arguments. In the CFileOp class, you could therefore write something like this:
RaiseEvent FileCopyComplete "c:\bootlog.txt", "c:\backup" |
This is all you need to do in the class module. Now let's see what the client code does.
If you're writing code in a form or a class module and you want to receive events from an object, you must declare a reference to that object in the declaration section of the module, using the WithEvents keyword:
' You can use Public, Private, or Dim, as needed. Dim WithEvents FOP As CFileOp |
You should be aware of a few facts about the WithEvents clause:
Private Sub Form_Load() Set FOP = New CFileOp End Sub |
At this point, Visual Basic has all the information it needs to respond to events raised by the object. In fact, if you look at the list portion of the leftmost combo box at the top of the code editor window, you'll see that the variable you have declared using WithEvents appears in the list, together with all the controls already on the form. Select it and move to the rightmost combo box control to choose the event that interests you. (In this example, there's only one such event.) As it happens for events coming from controls, Visual Basic automatically creates the procedure template for you, and all you have to do is fill it with some meaningful code:
Private Sub Fop_FileCopyComplete(File As String, DestPath As String) MsgBox "File " & File & " has been copied to " & DestPath End Sub |
Now that all syntax details have been explained, it's time to complete the CFileOp class, which is able to copy one or multiple files and provide feedback to the caller. As you'll see shortly, this initial sample program provides us with the playground for more complex and interesting programming techniques based on events.
Let's create a class module and name it CFileOp. This class exposes a few properties that allow the client to decide which files should be copied (FileSpec, Path, and Attributes properties) and a method that starts the actual copy process. As I indicated, the class also exposes a FileCopyComplete event:
' The CFileOp class module Event FileCopyComplete(File As String, DestPath As String) Private m_FileSpec As String Private m_Filenames As Collection Private m_Attributes As VbFileAttribute Property Get FileSpec() As String FileSpec = m_FileSpec End Property Property Let FileSpec(ByVal newValue As String) ' Reset the internal Collection if a new file specification is given. If m_FileSpec <> newValue Then m_FileSpec = newValue Set m_Filenames = Nothing End If End Property Property Get Path() As String Path = GetPath(m_FileSpec) End Property Property Let Path(ByVal newValue As String) ' Get current file specification, and then substitute just the path. FileSpec = MakeFilename(newValue, GetFileName(FileSpec)) End Property Property Get Attributes() As VbFileAttribute Attributes = m_Attributes End Property Property Let Attributes(ByVal newValue As VbFileAttribute) ' Reset the internal Collection only if a new value is given. If m_Attributes <> newValue Then m_Attributes = newValue Set m_Filenames = Nothing End If End Property ' Holds the list of all the files that match FileSpec, ' plus any other file added by the client code (read-only property) Property Get Filenames() As Collection ' Build the file list "on demand", and only if necessary. If m_Filenames Is Nothing Then ParseFileSpec Set Filenames = m_Filenames End Property ' Parses a file specification and attributes and adds ' the resulting filename to the internal m_Filenames Collection Sub ParseFileSpec(Optional FileSpec As Variant, _ Optional Attributes As VbFileAttribute) Dim file As String, Path As String ' Provide a default for arguments. If IsMissing(FileSpec) Then ' In this case, we need a file specification. If Me.FileSpec = "" Then Err.Raise 1001, , "FileSpec undefined" FileSpec = Me.FileSpec Attributes = Me.Attributes End If ' Create the internal Collection if necessary. If m_Filenames Is Nothing Then Set m_Filenames = New Collection Path = GetPath(FileSpec) file = Dir$(FileSpec, Attributes) Do While Len(file) m_Filenames.Add MakeFilename(Path, file) file = Dir$ Loop End Sub Sub Copy(DestPath As String) Dim var As Variant, file As String, dest As String On Error Resume Next For Each var In Filenames file = var dest = MakeFilename(DestPath, GetFileName(file)) FileCopy file, dest If Err = 0 Then RaiseEvent FileCopyComplete(file, DestPath) Else Err.Clear End If Next End Sub ' Support routines that parse a filename. They are used internally ' but are also exposed as Public for convenience. Sub SplitFilename(ByVal CompleteName As String, Path As String, _ file As String, Optional Extension As Variant) Dim i As Integer ' Assume there isn't any embedded path. Path = "": file = CompleteName ' Backward search for a path delimiter For i = Len(file) To 1 Step -1 If Mid$(file, i, 1) = "." And Not IsMissing(Extension) Then ' We have found an extension, and the caller asked for it. Extension = Mid$(file, i + 1) file = Left$(file, i - 1) ElseIf InStr(":\", Mid$(file, i, 1)) Then ' Paths don't have a trailing backslash. Path = Left$(file, i) If Right$(Path, 1) = "\" Then Path = Left$(Path, i - 1) file = Mid$(file, i + 1) Exit For End If Next End Sub Function GetPath(ByVal CompleteFileName As String) As String SplitFilename CompleteFileName, GetPath, "" End Function Function GetFileName(ByVal CompleteFileName As String) As String SplitFilename CompleteFileName, "", GetFileName End Function Function MakeFilename(ByVal Path As String, ByVal FileName As String, _ Optional Extension As String) As String Dim result As String If Path <> "" Then ' Path might include a trailing backslash. result = Path & IIf(Right$(Path, 1) <> "\", "\", "") End If result = result & FileName If Extension <> "" Then ' Extension might include a dot. result = result & IIf(Left$(Extension, 1) = ".", ".", "") _ & Extension End If MakeFilename = result End Function |
The structure of the class should be self-evident, so I'll just explain a few minor details. When you assign a value to either the FileSpec or the Attributes property, the class resets an internal m_Filenames Collection variable. When eventually the Filenames Public property is referenced—from outside or inside the class module—the corresponding Property Get procedure checks whether the file list should be rebuilt, and if so, it invokes the ParseFileSpec method. This method could have been made Private to the class module, but keeping it Public adds some flexibility, as I'll show in the "Filtering Input Data" section, later in this chapter. At this point, everything is ready for the Copy method, which requires only the DestPath argument to learn where files are to be copied and which can raise a FileCopyComplete event back in the client code. All the other functions—SplitFilename, GetPath, GetFilename, and so on—are support routines used for parsing filenames and paths. They're also exposed as Public methods, however, because they can be useful to the client code as well.
Add a form module to your project, and add a few controls, as depicted in Figure 7-2:
Figure 7-2. The preliminary version of the CFileOp sample application at design time.
Use the following code to help you decide what names to use for your controls. (Or you can just load the demonstration program from the companion CD). I've used self-explanatory names for controls, so you shouldn't have problems understanding the function of each one of them. This is the code in the form module:
' The client Form1 module Dim WithEvents Fop As CFileOp Private Sub Form_Load() ' WithEvents objects can't be auto-instancing. Set Fop = New CFileOp End Sub Private Sub cmdParse_Click() Dim file As Variant InitFOP lstFiles.Clear For Each file In Fop.Filenames lstFiles.AddItem file Next picStatus.Cls picStatus.Print "Found " & Fop.Filenames.count & " files."; End Sub Private Sub cmdCopy_Click() InitFOP Fop.Copy txtDestPath.Text End Sub ' A useful routine shared by many procedures in the form Private Sub InitFOP() Fop.FileSpec = txtFilespec Fop.Attributes = IIf(chkHidden, vbHidden, 0) + _ IIf(chkSystem, vbSystem, 0) End Sub ' Trapping events from CFileOp class Private Sub Fop_FileCopyComplete(File As String, DestPath As String) picStatus.Cls picStatus.Print "Copied file " & File & " ==> " & DestPath; End Sub |
To get a taste of how events actually work, there's nothing that beats a trace session. Set some breakpoints, type some reasonable paths for the source and destination, click on the Parse or Copy button (be careful not to overwrite the files you need!), and press F8 to see the code come alive before your eyes.
In its simplicity, the CFileOp class module is a good piece of code that can be extensively improved with the addition of many new features. What's more important from our standpoint is that most of these additions demonstrate compelling new techniques you can implement with events.
In its first version, the CFileOp class simply parses the value assigned to the FileSpec property and builds the list of the files to be copied, taking into account the value of the Attributes property. Unfortunately, the client code has no way to filter out particular files, for example, temporary or backup files or files with specific names. Thanks to the flexibility offered by events, however, you can add this capability in just a matter of seconds. You only have to add a new event declaration to the class:
' In the declaration section of the CFileOp class module Event Parsing(file As String, Cancel As Boolean) |
and add a few lines (shown here in boldface) inside the ParseFileSpec routine:
' ... inside the ParseFileSpec routine Dim Cancel As Boolean Do While Len(file) Cancel = False RaiseEvent Parsing(file, Cancel) If Not Cancel Then m_Filenames.Add MakeFilename(Path, file) End If file = Dir$ Loop |
Taking advantage of the new event in the client code is even easier. Let's say that you want to exclude temporary files from the copy process. All you have to do is trap the Parsing event and set its Cancel parameter to True when the class is about to copy a file you aren't interested in, as this code demonstrates:
' In the client form module Private Sub Fop_Parsing(file As String, Cancel As Boolean) Dim ext As String ' GetExtension is a handy method exposed by CFileOp. ext = LCase$(Fop.GetExtension(file)) If ext = "tmp" Or ext = "$$$" Or ext = "bak" Then Cancel = True End Sub |
So far, you have seen that the FileCopyComplete event is raised immediately after the copy operation because it's intended to give the client code a clue that something has occurred inside the class module. A more flexible class would envision the capability for the client to intervene even before the operation takes place. In other words, what you need is a WillCopyFile event:
Enum ActionConstants foContinue = 1 foSkip foAbort End Enum Event WillCopyFile(file As String, DestPath As String, _ Action As ActionConstants) |
I could have used a standard Boolean Cancel argument, but an enumerated value adds a lot of flexibility. You raise a WillCopyFile event in the Copy method, just before doing the actual copy. Here's the revised procedure, with added or modified statements showed in boldface:
Sub Copy(DestPath As String) Dim var As Variant, file As String, dest As String Dim Action As ActionConstants On Error Resume Next For Each var In Filenames file = var dest = MakeFilename(DestPath, GetFileName(file)) Action = foContinue RaiseEvent WillCopyFile(file, dest, Action) If Action = foAbort Then Exit Sub If Action = foContinue Then FileCopy file, dest If Err = 0 Then RaiseEvent FileCopyComplete(file, GetPath(dest)) Else Err.Clear End If End If Next End Sub |
To take advantage of this new event, the client form module has been enriched with a Confirm CheckBox control that, if selected, gives the user control over the copy process. Thanks to the WillCopyFile event, you can implement this new feature with just a handful of statements:
Private Sub Fop_WillCopyFile(File As String, DestPath As String, _ Action As ActionConstants) ' Exit if user isn't interested in file-by-file confirmation. If chkConfirm = vbUnchecked Then Exit Sub Dim ok As Integer ok = MsgBox("Copying file " & File & " to " & DestPath & vbCr _ & "Click YES to proceed, NO to skip, CANCEL to abort", _ vbYesNoCancel + vbInformation) Select Case ok Case vbYes: Action = foContinue Case vbNo: Action = foSkip Case vbCancel: Action = foAbort End Select End Sub |
You can use the mechanism of prenotification events to much greater effect than just as a means for allowing or preventing the completion of a given process. In fact, a significant point of these types of events is that most or all their arguments are passed by reference and can therefore be altered by the caller. This is similar to what you usually do with the KeyAscii argument passed to the KeyPress event procedure of a standard control. For example, you might decide that all BAK files should be copied to a different directory:
' Inside the WillCopyFile event procedure (in the client)... If LCase$(Fop.GetExtension(file)) = "bak" Then DestPath = "C:\Backup" End If |
In most cases, the best means for a class to return an error to the client is by using the standard Err.Raise method. This allows the client to get a definitive confirmation that something went wrong and that appropriate steps must be taken. However, when a class communicates with its clients through events, you can explore a few alternatives to the Err.Raise method. For example, if the CFileOp class isn't able to copy a particular file, should the entire copy process be terminated? Needless to say, only the client code knows the answer, so the right thing to do is to ask it—by means of an event, of course:
Event Error(OpName As String, File As String, File2 As String, _ ErrCode As Integer, ErrMessage As String, Action As ActionConstants) |
You see that I've added a generic OpName argument so that the same Error event can be shared by all the methods in the class module. Adding support for this new event in the Copy method requires little effort:
' Inside the Copy method in the CFileOp class module... FileCopy File, dest If Err = 0 Then RaiseEvent FileCopyComplete(File, DestPath) Else Dim ErrCode As Integer, ErrMessage As String ErrCode = Err.Number: ErrMessage = Err.Description RaiseEvent Error("Copy", File, DestPath, ErrCode, _ ErrMessage, Action) ' Report the error to the client if user aborted the process. If Action = foAbort Then ' You need to cancel error handling, otherwise the Err.Raise ' method won't return the control to the client. On Error GoTo 0 Err.Raise ErrCode, , ErrMessage End If Err.Clear End If |
The client now has the ability to trap errors and decide what to do with them. For example, an "Error 76 - Path not found" means that either the source or the destination isn't valid, so there isn't any point in continuing the operation:
Private Sub Fop_Error(OpName As String, File As String, File2 As String, _ ErrCode As Integer, ErrMessage As String, Action As ActionConstants) If ErrCode = 76 Then MsgBox ErrMessage, vbCritical Action = foAbort End If End Sub |
This code doesn't test the OpName argument: This is an intentional omission because the same code can manage errors raised by all methods in the class. Also note that the class passes both ErrCode and ErrMessage by reference, and the client can, for example, modify them at will:
' Use a custom error scheme for this client. If OpName = "Copy" Then ErrCode = ErrCode + 1000: ErrMessage = "Unable to Copy" ElseIf OpName = "Move" Then ErrCode = ErrCode + 2000: ErrMessage = "Unable to Move" End If Action = foAbort |
The task of notifying the user about the progress of a process is among the most common uses for events. In a sense, each prenotification and postnotification event can be considered a signal that the process is active, so it could seem that a separate Progress event is superfluous. But you can offer your clients better service if you also expose an event that clients can use to inform the user about the progress of a task, for example using a progress bar that shows the percentage of the job accomplished. The trick is to raise this event only when the actual percentage changes so that you don't force the client to continuously update the user interface without any real reason to do so:
Event ProgressPercent(Percent As Integer) |
After writing some classes that expose the ProgressPercent event, you realize that you can put most of the logic for this event in a generic procedure, which can be reused in all your class modules:
Private Sub CheckProgressPercent(Optional NewValue As Variant, _ Optional MaxValue As Variant) Static Value As Variant, Limit As Variant Static LastPercent As Integer Dim CurrValue As Variant, CurrPercent As Integer If Not IsMissing(MaxValue) Then Limit = MaxValue If IsMissing(NewValue) Then Err.Raise 9998, , _ "NewValue can't be omitted in the first call" Value = NewValue Else If IsEmpty(Limit) Then Err.Raise 9999, , "Not initialized!" Value = Value + IIf(IsMissing(NewValue), 1, NewValue) End If CurrPercent = (Value * 100) \ Limit If CurrPercent <> LastPercent Or Not IsMissing(MaxValue) Then LastPercent = CurrPercent RaiseEvent ProgressPercent(CurrPercent) End If End Sub |
The structure of the CheckProgressPercent routine is somewhat contorted because it has to account for many possible default values of its arguments. You can call it with two, one, or no arguments. You call it with two arguments when you want to reset its internal counters Value and Limit. You call it with just one argument when you simply want to increment Value. Finally, you call it with no arguments when you increment Value by 1 (a case so common that it deserves a courtesy treatment). This flexible scheme simplifies how the routine is invoked by the methods in the class, and in most cases you just need two statements to fire the Progress event at the right time:
' In the Copy method On Error Resume Next CheckProgressPercent 0, Filenames.Count ' Reset internal counters. For Each var In Filenames CheckProgressPercent ' Increment by 1. File = var ... |
The CheckProgressPercent routine is optimized and raises a ProgressPercent event only when the percentage actually changes. This allows you to write code in the client without worrying about tracing the changes yourself:
Private Sub Fop_ProgressPercent(Percent As Integer) ShowProgress picStatus, Percent End Sub ' A reusable routine that prints to a generic PictureBox Private Sub ShowProgress(pic As PictureBox, Percent As Integer, _ Optional Color As Long = vbBlue) pic.Cls pic.Line (0, 0)-(pic.ScaleWidth * Percent / 100, _ pic.ScaleHeight), Color, BF pic.CurrentX = (pic.ScaleWidth - pic.TextWidth(CStr(Percent) _ & " %")) / 2 pic.CurrentY = (pic.ScaleHeight - pic.TextHeight("%")) / 2 pic.Print CStr(Percent) & " %"; End Sub |
The CFileOp class that you'll find on the companion CD includes many other improvements, such as the support for Move and Delete commands, and the inclusion of a Parsing event that lets the client filter out specific files during the parsing process. (See Figure 7-3.)
Figure 7-3. This version of the CFileOp demonstration program supports multiple filespecs, wildcards, additional file commands, a progress bar with a percentage indicator, and full control of individual file operations.
Now that I have shown you several ways to exploit events in your own classes in hopes of piquing your interest, I admit that I've reserved the best news for the grand finale (of this section about events, at least). In fact, what I've purposefully left out is that the event mechanism on which WithEvents is based is compatible with COM and with all the events raised by Visual Basic's own forms and controls.
This mechanism is also known as event multicasting. This term means that an object can raise events in all the client modules containing a WithEvents variable that points to that object. This might seem to be a negligible detail until you see how far-reaching its consequences are.
As you all know, a form module is always able to trap events from its own controls. Before multicasting, trapping controls' events in the parent form's module was the best thing a programmer could do. Well, it probably is still the best thing that you can do with events, but surely it isn't the only one. In fact, you can declare an explicit object variable, let it point to a particular control, and use it to trap that control's events. The multicasting mechanism ensures that the variable receives the event notification wherever it is declared! This means that you can move the variable to another module in the program (or to another form, or class, or actually anything but a standard BAS module) and still react to all the events raised by the control.
Let's see what this means to us, mere Visual Basic programmers. To show multicasting in action, you just need a very simple CTextBxN class module, whose only purpose is to reject any nondigit keys from a TextBox control:
Public WithEvents TextBox As TextBox Private Sub TextBox_KeyPress(KeyAscii As Integer) Select Case KeyAscii Case 0 To 31 ' Accept control chars. Case 48 To 57 ' Accept digits. Case Else KeyAscii = 0 ' Reject anything else. End Select End Sub |
To test drive this class, create a form, place a TextBox control on it, and add this code:
Dim Amount As CTextBxN Private Sub Form_Load() Set Amount = New CTextBxN Set Amount.TextBox = Text1 End Sub |
Run the program, and try to type a nondigit key in Text1. After a few attempts, you'll realize that the CTextBxN class is trapping all the KeyPress events raised from Text1 and processing the validation code on behalf of the Form1 module. Seems interesting, eh? The real power of this technique becomes apparent when you have other numerical fields on your form, for example, a new Text2 control that holds a percentage value:
Dim Amount As CTextBxN, Percentage As CTextBxN Private Sub Form_Load() Set Amount = New CTextBxN Set Amount.TextBox = Text1 Set Percentage = New CTextBxN Set Percentage.TextBox = Text2 End Sub |
Instead of creating distinct event procedures in the parent form module, each one validating the keys going to a distinct TextBox control, you've encapsulated the validation logic in the CTextBxN class once, and you're now reusing it over and over again. And you can do it for all the fields in Form1, as well as for any number of fields in any form of your application (not to mention all the future applications that you'll write from now on). This is reusable code!
The benefits of multicasting shouldn't make you forget that CTextBxN is a regular class module, which can be improved with properties and methods. Just as an example, let's add three new properties that make the class more useful: IsDecimal is a Boolean property that, if True, allows decimal values; FormatMask is a string used to format the number when the focus leaves the control; and SelectOnEntry is a Boolean property that states whether the current value should be highlighted when the control gets the focus. Here's the new version of the class:
Public WithEvents TextBox As TextBox Public IsDecimal As Boolean Public FormatMask As String Public SelectOnEntry As Boolean Private Sub TextBox_KeyPress(KeyAscii As Integer) Select Case KeyAscii Case 0 To 31 ' Accept control chars. Case 48 To 57 ' Accept digits. Case Asc(Format$(0.1, ".")) ' Accept the Decimal separator. If Not IsDecimal Then KeyAscii = 0 Case Else KeyAscii = 0 ' Reject anything else. End Select End Sub Private Sub TextBox_GotFocus() TextBox.Text = FilterNumber(TextBox.Text, True) If SelectOnEntry Then TextBox.SelStart = 0 TextBox.SelLength = Len(TextBox.Text) End If End Sub Private Sub TextBox_LostFocus() If Len(FormatMask) Then TextBox.Text = Format$(TextBox.Text, FormatMask) End If End Sub ' Code for FilterNumber is omitted. (See Chapter 3.) |
Using the new properties is a pleasure. Just set them in the Form_Load procedure and then enjoy your smarter TextBox controls:
' In the Form_Load event procedure Amount.FormatMask = "#,###,###" Amount.SelectOnEntry = True Percentage.FormatMask = "0.00" Percentage.IsDecimal = True Percentage.SelectOnEntry = True |
Because CTextBxN is a regular class module, it can even declare and raise its own custom events. This ability is really interesting: The class "steals" controls' events from the original form but then sends the form other events. This permits a degree of sophistication that couldn't be possible otherwise. To demonstrate this concept in action, I'll show you how to add to the class full support for validation against Min and Max properties. In a regular program, validation is performed in the Validate event on the parent form. (See Chapter 3.) But now you can trap that event and preprocess it against your new custom properties:
' In the CTextsBxN class module Event ValidateError(Cancel As Boolean) Public Min As Variant, Max As Variant Private Sub TextBox_Validate(Cancel As Boolean) If Not IsEmpty(Min) Then If CDbl(TextBox.Text) < Min Then RaiseEvent ValidateError(Cancel) End If If Not IsEmpty(Max) Then If CDbl(TextBox.Text) > Max Then RaiseEvent ValidateError(Cancel) End If End Sub |
If the class detects a potential out-of-range error, it just raises a ValidationError in the original form, passing the Cancel argument by reference. In the client form module, you can therefore decide whether you actually want to abort the shift focus, exactly as you would do under normal circumstances:
' Now Percentage must be declared using WithEvents. Dim WithEvents Percentage As CTextBxN Private Sub Form_Load() ' ... Percentage.Min = 0 Percentage.Max = 100 End Sub ' ... Private Sub Percentage_ValidateError(Cancel As Boolean) MsgBox "Invalid Percentage Value", vbExclamation Cancel = True End Sub |
Alternatively, you could set Cancel to True in the class module and give the client code an opportunity to reset it to False. These are just details. The important point is that you're now in complete control of what happens inside the control, and you're doing that with a minimum amount of code on the form itself because most of the logic is encapsulated in the class module.
Now that you know how you can have a class module trap events from a control, you can extend the technique to multiple controls as well. For example, you can trap events from a TextBox control and a tiny ScrollBar control beside it to simulate those fancy spin buttons that are so trendy in many Windows applications. Or you can rework the scrollable form example in Chapter 3 and build a CScrollForm class module that traps events from a form and its two companion scroll bars. Instead of rehashing such simple tasks, I prefer to focus on something new and more interesting. In the following example, I'll show you how easily you can create calculated fields using multicasting. This example is a bit more complex, but I'm sure that in the end you'll be glad to have spent some time on it.
The CTextBoxCalc class module I built is able to trap the Change event from up to five distinct TextBox controls (the independent fields) and use this capability to update the contents of another Textbox on the form (the dependent field) without any intervention from the main program. To create a generic calculated field, I needed to devise a way for the client code to specify the expression that must be reevaluated each time one of the independent controls raises a Change event. To this end, the class exposes a SetExpression method that accepts an array of parameters. Each parameter can be a reference to a control, a number, or a string that represents one of the four math operators. Look, for example, at the following code:
' Example of client code that uses the CTextBoxCalc class ' txtTax and txtGrandTotal depend on txtAmount and txtPercent. Dim Tax As New CTextBoxCalc, GrandTotal As New CTextBoxCalc ' Link the class to the control on which the result is to be displayed. Set Tax.TextBox = txtTax ' Set the expression "Amount * Percent / 100". Tax.SetExpression txtAmount, "*", txtPercent, "/", 100 ' Create a GrandTotal calculated field, equal to "Amount + Tax". Set GrandTotal.TextBox = txtGrandTotal GrandTotal.SetExpression txtAmount, "+", txtTax |
The intricacy of the CTextBoxCalc class derives mostly from the need to parse the arguments passed to the SetExpression method. I kept this intricacy to a minimum and renounced sophisticated features such as allowing different priorities among operators, bracketed subexpressions, and functions. This leaves the four math operators, which are evaluated in a strict left-to-right order. (For example, "2+3*4" evaluates to 20 instead of 14.) On the other hand, the complete class module has just 80 lines of code:
' The complete source code for CTextBoxCalc class Public TextBox As TextBox Public FormatMask As String ' We can trap events from max 5 TextBox controls. Private WithEvents Text1 As TextBox Private WithEvents Text2 As TextBox Private WithEvents Text3 As TextBox Private WithEvents Text4 As TextBox Private WithEvents Text5 As TextBox ' Here we store the arguments passed to SetExpression. Dim expression() As Variant Sub SetExpression(ParamArray args() As Variant) Dim i As Integer, n As Integer ReDim expression(LBound(args) To UBound(args)) As Variant For i = LBound(args) To UBound(args) If IsObject(args(i)) Then ' Objects must be stored as such, using Set. Set expression(i) = args(i) If TypeName(args(i)) = "TextBox" Then n = n + 1 If n = 1 Then Set Text1 = args(i) If n = 2 Then Set Text2 = args(i) If n = 3 Then Set Text3 = args(i) If n = 4 Then Set Text4 = args(i) If n = 5 Then Set Text5 = args(i) End If Else ' Store number and strings without the Set keyword. expression(i) = args(i) End If Next End Sub ' Here we actually evaluate the result. Sub EvalExpression() Dim i As Integer, opcode As Variant Dim value As Variant, operand As Variant On Error GoTo Error_Handler For i = LBound(expression) To UBound(expression) If Not IsObject(expression(i)) And VarType(expression(i)) _ = vbString Then opcode = expression(i) Else ' This works with numbers and Text (default) properties alike. operand = CDbl(expression(i)) Select Case opcode Case Empty: value = operand Case "+": value = value + operand Case "-": value = value - operand Case "*": value = value * operand Case "/": value = value / operand End Select opcode = Empty End If Next If Len(FormatMask) Then value = Format$(value, FormatMask) TextBox.Text = value Exit Sub Error_Handler: TextBox.Text = "" End Sub ' Here we trap events from the independent fields. Private Sub Text1_Change() EvalExpression End Sub ' ... Text2-Text5 Change procedures .... (omitted) |
The class can trap events from a maximum of five TextBox independent controls, but the expression could refer to just one or two of them. This is OK: If a WithEvents variable isn't assigned and remains Nothing, it simply stays inert and never raises events in the class. It isn't useful but doesn't do any harm either.
To get an idea of the potential of this class, run the demonstration program on the companion CD and see how you can grow a spreadsheet-like form that accepts data in a couple of fields and automatically updates the other two fields. (See Figure 7-4 for an example of how that might work.) The same application demonstrates both the CTextBxN and the CTextBoxCalc classes.
Figure 7-4. You can create smart forms that contain live calculated fields by using reusable external class modules exclusively.
Exploiting the event multicasting features in your application is among the best favors you can do yourself. Before you get too carried away, though, you should be aware that there are a few problems with this technique.
Dim WithEvents TextBox As TextBox Private Sub Form_Load() ' Raises a Type Mismatch run-time error. Set TextBox = Text1(0) End Sub |
This bug prevents you from dynamically creating a new control from a control array and then trapping its events using multicasting. Unfortunately, there isn't any known solution to this problem. Curiously, this bug doesn't manifest itself if the control you're assigning to a WithEvents variable is an ActiveX control authored in Visual Basic.